嗨嗨~昨天 D10 走完 Iceberg 那條漏斗,catalog、metadata、manifest list、manifest 到 data file,一張表由幾百萬個 Parquet 組成,最後剪到剩下要打開的那幾個。收尾那句「已經決定打開這個 Parquet 了,頁面裡怎麼只解該解的列,是明天的晚物化」,就是今天要挖的這一截。
先看一句很短的 SQL:
SELECT s FROM t WHERE g = 5;
t 是 D8/D9 那份 500 萬列的 sortbench.parquet,g 是 id mod 8 的整數欄,s 是每列 32 byte 的 md5 字串,g = 5 平均只留 12.5%,也就是 62 萬列,其他 438 萬列不要。
兩條路可以走:
g 跟 s 兩欄整段解出來,套 filter 篩,把不要的 438 萬列 s 丟掉,解了才丟。g,跑 filter 拿到「62 萬列在哪」的位圖,再回頭只解那 62 萬列的 s。兩條路的結果一模一樣,代價差 8 倍,差在有沒有把不會用到的 s 也解出來,而 s 是 32 byte 隨機字串、壓縮比只有 2 倍,解一列是純 memcpy 加 UTF-8 檢查,並不便宜。
今天要問四個問題:
物化 = 把一個值變成記憶體裡真實存在、可以直接讀的一塊資料,來源可能是從磁碟解碼出來(本篇),也可能是運算子當場算出來(D5 的中間結果)。
Parquet 磁碟上不是明文的整數跟字串,而是 dictionary 編碼、bit-packing、RLE 之後再套 Snappy 壓縮的一坨 bytes,要讓 filter、TopK、hash join 這些運算子拿去比大小、做雜湊,得先解壓縮、解碼、還原成 Arrow 的 Int32Array / StringArray 塞進記憶體,這個「還原」的動作就是物化。
D5 講的是運算子中間結果(例如 hash join 的 build side)的物化,那是 CPU 在算的過程中順手產出一塊 Arrow buffer;今天講的是存取路徑上的物化,從磁碟 bytes 還原到記憶體 Array。兩件事共用「物化」這個詞,因為對下游運算子而言長得一樣,都是「可以直接讀的一塊 Arrow 資料」。
D9 說 Parquet「只讀該讀的位元組」有兩個條件,projection 對得到 column chunk、predicate 對得到 footer 的 min/max,今天要多加一條:物化的時機對得到 filter,前兩條決定讀哪些 bytes,這一條決定讀進來的 bytes 之中,要 decode 哪些列。
拆開 SELECT s FROM t WHERE g = 5 的兩個 plan。
早物化 plan
g 跟 s 兩欄,每欄 5 個 row group 全解碼,成 Int32Array 跟 StringArray。g = 5,得到 selection vector v。s 裡符合的 62 萬列,丟出去。s 有 500 萬列 × 32 byte = 160 MB 未壓縮,全部要物化到記憶體,然後丟掉 87.5%。
晚物化 plan
g 那一欄,解碼。g = 5,得到 selection vector v(62 萬個 true)。s 那一欄的原始 bytes,用 v 當導引,只解 v = true 的 62 萬列所在的位置。s 只解 62 萬列 × 32 byte = 20 MB,g 兩條 plan 都要全解,但 g 是 8 個相異值的 dictionary,全解也才 2.6 KB,可以忽略,差距全在 s,160 MB 對上 20 MB 就是 8 倍。
省的不是 I/O,該讀的 page 還是要 range GET 進來,省的是 decode CPU,D2 那條 vectorized loop 寫成的核心熱點一次少了 8 倍。這也是為什麼晚物化在 OLAP 場景幾乎是預設選項,query 的 selectivity 越低,省得越多。
真實 predicate 常常有兩個 conjunct,例如 WHERE A > 35 AND B = 'F',Arrow blog 那張圖畫的四步驟:
A、B 兩欄的相關頁,D9 的 footer min/max 跟 page index 已經先剪過一輪,A 那些整段 max < 35 的頁根本不會被讀進來,B 沒有 'F' 的頁也一樣。A,跑第一個 predicate,解碼 A,套 A > 35,得到 selection vector v1。B,跑第二個 predicate,跟 v1 交集,解碼 B(有些實作為了省事直接全解 B 再算 AND,更精細的做法只解 v1 為 true 的位置),套 B = 'F',跟 v1 交集得到 v2。SELECT 列表裡其他欄(例如 id, s),v2 對到 page 上,整頁沒 true 的頁跳過,有 true 的頁只 decode 該解的列。DataFusion 這條路走的是 datafusion/datasource-parquet/src/row_filter.rs,EXPLAIN ANALYZE 打開會看到 pushdown_rows_matched 這種指標,它印的就是 v2 剩多少列,比 row_groups_pruned_statistics 更細一級。
從 D10 那條漏斗看,這是第三階段,D10 剪 data file、D9 剪 row group、今天在 page 內剪列,三階段做的是同一件事,證明一定沒有就跳過,只是尺度越縮越小,同構重複。
思考題那句 A > 35 AND B = 'F',為什麼是先跑 A 還是先跑 B,答案兩層。
選擇性:越早剪掉越多列越好。假設 B = 'F' 只留 5%、A > 35 只留 50%,先跑 B = 'F' 讓後面只要對 5% 的位置跑 A > 35,反過來要對 50% 的位置跑 B = 'F',是十倍的差距。planner 拿 column stats 估選擇性,行話叫 selectivity estimation。
解碼成本:解碼便宜的先跑。int32 比 varchar 便宜,dictionary 編碼比 plain 便宜,有 page-level stats 可以整頁跳過的更便宜,DataFusion 的 RowFilter 會用 PhysicalExpr::analyze 收集這些成本。
兩件事會打架。A > 35 選擇性 50%、B = 'F' 選擇性 5%,照選擇性應該先 B,但如果 A 是 int32、B 是 varchar 沒有 dictionary、要跑整頁 UTF-8 檢查,解 B 每列的成本可能是 A 的十倍,這時候「花貴的成本剪 95%」跟「花便宜的成本剪 50% 再花貴的成本剪 90%」哪邊快,得看常數。
實務上 DataFusion 沒有 join 那種完整的 cost model,用啟發式,把 filter 表達式按估計選擇性 × 估計解碼成本排序,選擇性高、成本低的先跑,夠用,不完美,這也是為什麼手動改寫 predicate 順序有時候會被引擎重排掉、有時候會被保留,不是隨機,是啟發式的裁量邊界。
「只讀 filter 欄、不讀 payload 欄」對欄式引擎不是額外的功夫,是檔案格式天然支援。Parquet 每一欄的 column chunk 是分開放的,讀 g 那段 bytes 完全不需要碰 s 的位址空間,第一步「只讀 filter 欄的 page」在欄式檔上是免費的。
列式引擎做不到,一列所有欄擠在一起(row-oriented layout),讀 filter 欄那幾個 byte 就等於把整列 payload 的 byte 也讀進來了,I/O 已經付了,CPU 再跳過不要的欄也回不了本,晚物化在列式引擎上頂多能省 decode,省不了 I/O。
D1 講「Arrow / 欄式為什麼快」時著重在 CPU vectorized,這裡是同一件事在存取路徑上的表現,欄式讓 I/O 跟 decode 都能按 predicate 縮,跟 SIMD 是兩條互不干擾的加成,都受欄式布局所賜。
三個常見翻車情境,逆著上面的假設看就清楚了:
WHERE g >= 0(全留)或 WHERE g < 100(留 99%),晚物化多跑一趟,先解 filter 欄、算 selection vector、再解 payload,省下的 decode 不多,卻付了一次 filter 開銷跟一次額外的頁定位。planner 偵測到選擇性太高就退回早物化。WHERE md5(s) LIKE 'abc%',解 s、每列算 md5,再拿去挑一個 int 欄,filter 欄的成本已經蓋過整個 payload,晚物化白搭,這種 predicate 上做 pushdown 反而不如把 filter 拉到 payload 解完之後跑。三種情境的共同點是,晚物化的獲益來自「不解該解的以外的列」,當 filter 欄本來就得全解、payload 本來就便宜、或 selection 稀疏到每頁都得碰,那個「不解」就撈不到本。這也是為什麼 DataFusion 沒把晚物化寫死,RowFilter 是一個 pushdown 選項,planner 判斷值得才會啟用。
漏斗到這裡收尾
D10 剪枝 data file、D9 剪枝 row group、今天在 page 內只解該解的列,三層剪枝背後同一個公式:證明一定沒有就跳過。
漏斗還有一個小尾巴要接,晚物化拿到的 selection vector 還不是最終要輸出的位圖,還要跟 delete file 交集,Iceberg 的 positional delete 講「這個 data file 的第 42、73、118 列被刪了」,equality delete 講「所有 id = 999 的列被刪了」,SELECT 真正吐出的 = predicate 剩下的 減 被刪掉的,檔案不能改的時候「這列被刪了」寫在哪、怎麼跟 selection vector 交集,是明天 D12 的題。
留一個沒有標準答案的問題:A > 35 AND B = 'F',如果 A > 35 選擇性 50%、B = 'F' 選擇性 5%,但 A 是 int32、B 是 varchar,兩件事哪個先做?提示,選擇性、解碼成本、有沒有 bloom filter / page index 可以幫忙判斷,答案不只一種。
那就明天見~